PythonでのTDDを用いたOOP開発の簡単な例 - パート2
著者: Leonardo Giordani - 10/09/2015
はじめに
この小さなシリーズの最初のパート(PythonでのTDDを用いたOOP開発の簡単な例 - パート1)では、強力な pytest パッケージを使って Python による TDD を紹介しました。私たちは、Pythonがbin()組み込み関数で提供するデフォルトのバイナリ表現よりも少し便利なBinaryクラスを提供するシンプルなライブラリを一緒に開発しました。 このパートでは、ライブラリの開発を進めながら、固定サイズのバイナリ数の実装について説明します。固定サイズのバイナリは、2の補数技術を用いて負の数を表現することもでき、これはテストする上で重要なポイントになります。
できあがったクラスのインターフェイスや振る舞いについて、たまたま気に入らない決定があるかもしれません。この投稿は、具体的なTDDセッションをお見せするためのものですので、テストを変更したり、私よりも優れた解決策を思いついたりすることは全く自由です。私はいつでも新しいことを学びたいと思っていますので、ご遠慮なくご連絡ください。
最初の記事で提案したように、私がテストを書くところまで記事を追ってみてください。そして、自分のクラスの実装に移り、すべてのテストに合格するようにしてください。これがTDDを学ぶ最良の方法であり、実際に適用することになります。
固定サイズのバイナリ
情報を格納する電子回路(フリップフロップなど)を作ると、すぐに2進数や固定サイズの量を扱うようになります。桁数を制限すると、すぐに表現できる最大数の制限が発生し、大きな数字をどうするかを決めなければならない。また、負の数の表現についても問題があります。1と0の2つの記号しか使用できないため、負の値の「構文」を決めなければなりません。
これらの問題については、以下のWikipediaの記事に多くの情報が掲載されています。整数のオーバーフロー」と「符号付き数値の表現」です。また、ビット演算については、その一部が実装されることになりますので、このページもチェックしてください。
オブジェクトインターフェース
そこで、以下のようなインターフェイスを提供するSizeBinaryというクラスを作成することにします。
ビット単位のサイズと値を与えてインスタンス化することができます。値はオプションで、指定されない場合は0です。
値は、インスタンス化した後にset()メソッドで設定できます。
表現可能な最大値を超える値をインスタンス化または設定した場合、オブジェクトのoverflow属性はTrueになります。
最初の記事で紹介したBinaryクラスがサポートするすべてのデータ型で初期化できます。
また、Binary クラスと同様に、整数(例えば42は、2 進文字列の0b101010 など)、16 進文字列(0x2a)、ビット文字列(101010)に変換することができます。また、サイズの制限なくバイナリに変換することができます。
以下のようなバイナリ論理演算をサポートします。NOT、AND、OR、XOR、左シフト、右シフト(キャリーなし)などの2値論理演算に対応しています。
任意のサイズの2つのSizeBinaryオブジェクトに分割可能
1の補数と2の補数の両方の技術を用いて否定することができます。
というわけで、TDDの方法論に従って、これらの機能を利用するテストを書いてみます。この後、すべてのテストをパスさせるオブジェクトを開発します。前の記事でやったように、私がテストを書いているのを見てから、自分のクラスを書いてみてください。
初期化
SizeBinaryオブジェクトは、Binaryでサポートされているすべての初期化オプションをサポートしなければなりませんが、SizeBinaryを作成する際にサイズを指定することができる必要があるため、その主なインターフェースは異なります。最初のテストはとても簡単です。
code: python
def test_size_binary_init_int():
size_binary = SizeBinary(8, 6)
assert int(size_binary) == 6
def test_size_binary_init_int_overflow():
size_binary = SizeBinary(8, 512)
assert int(size_binary) == 0
assert size_binary.overflow == True
def test_size_binary_set():
size_binary = SizeBinary(8, 0)
size_binary.set(22)
assert str(size_binary) == '00010110'
assert size_binary.overflow == False
def test_size_binary_set_overflow():
size_binary = SizeBinary(8, 0)
size_binary.set(512)
assert str(size_binary) == '00000000'
assert size_binary.overflow == True
今回は、Binaryクラスですでにカバーされているケースをすべて取り上げます。ご覧のように、オブジェクトをインスタンス化するときにビットサイズを指定して、オーバーフロー条件をテストしています。その他の初期化や変換のテストは、Binaryのものとよく似ていて、ソースコードを見ればわかるので、ここでは省略します。
分割方法
SizeBinaryオブジェクトを任意のサイズの2つのBinaryオブジェクトに分割する方法を提供することが要件の1つです。テスト内容は
code: python
def test_size_binary_split():
size_binary8 = SizeBinary(8, '01010110')
size_binary4u, size_binary4l = size_binary8.split(4, 4)
assert (size_binary4u, size_binary4l) == (SizeBinary(4, '0101'), SizeBinary(4, '0110'))
def test_size_binary_split_asymmetric():
size_binary8 = SizeBinary(8, '01010110')
size_binary9u, size_binary3l = size_binary8.split(9, 3)
assert (size_binary9u, size_binary3l) == (SizeBinary(9, '000001010'), SizeBinary(3, '110'))
ご覧のように、ビット数が利用可能なビット数を超えた場合、スプリットは結果の値をパディングすることができます。
負数
負数を2進数で表現する方法は数多くありますが、正の数か負の数か、またどの方法が使われたかを2進数から見分ける方法はありません。それは、使用されているシステムの慣習の問題である。私たちは1の補数(one's complement) と2の補数(two's complement) を実装したいと考えていますが、これらはリンク先のWikipediaの記事で詳しく説明されています。(日本語: 補数) 正しい動作を確認するためのテストは
code: python
def test_size_binary_OC():
# 6 = 0b00000110 -> 0b11111001
size_binary = SizeBinary(8, 6)
assert size_binary.oc() == SizeBinary(8, '11111001')
# 7 = 0b00000111 -> 0b11111000
size_binary = SizeBinary(8, 7)
assert size_binary.oc() == SizeBinary(8, '11111000')
# 15 = 0b00001111 -> 0b11110000
size_binary = SizeBinary(8, 15)
assert size_binary.oc() == SizeBinary(8, '11110000')
# 15 = 0b0000000000001111 -> 0b1111111111110000
size_binary = SizeBinary(16, 15)
assert size_binary.oc() == SizeBinary(16, '1111111111110000')
def test_size_binary_TC():
# 6 = 0b00000110 -> 0b11111010
size_binary = SizeBinary(8, 6)
assert size_binary.tc() == SizeBinary(8, '11111010')
# 7 = 0b00000111 -> 0b11111001
size_binary = SizeBinary(8, 7)
assert size_binary.tc() == SizeBinary(8, '11111001')
# 15 = 0b00001111 -> 0b11110001
size_binary = SizeBinary(8, 15)
assert size_binary.tc() == SizeBinary(8, '11110001')
# 15 = 0b0000000000001111 -> 0b1111111111110001
size_binary = SizeBinary(16, 15)
assert size_binary.tc() == SizeBinary(16, '1111111111110001')
算術演算、インデックス、スライシング
基本的な数学的・論理的演算は、Binaryクラスに実装されているものと同じです。サイズの異なる2つのSizeBinary間で演算を行う場合に何が起こるかを確認するために、いくつかのテストが追加されています。結果は、2つのオペランドのうち大きい方のサイズを持つことが期待されます。
code: python
def test_binary_addition_int():
assert SizeBinary(8, 4) + 1 == SizeBinary(8, 5)
def test_binary_addition_binary():
assert SizeBinary(8, 4) + SizeBinary(8, 5) == SizeBinary(8, 9)
def test_binary_addition_binary_different_size():
assert SizeBinary(8, 4) + SizeBinary(16, 5) == SizeBinary(16, 9)
Binaryクラスでは右シフトの時だけでしたが、左シフトの時にもビットが足りなければドロップするようになりました。単純化のためにキャリーフラグは実装していません。つまり、空き領域外にシフトされたビットを取り出す方法はありません。実装するのは自由です。良い練習になりますが、最初にテストを書くことを忘れないでください。
インデックス作成とスライスの操作は,基本的にはバイナリの場合と同じです.スライス操作は、正しいサイズの新しいSizeBinaryを生成します。
いよいよ実装です
さあ、優秀なOOPプログラマーになって、ソースコードにあるすべてのテストに合格するクラスを書きましょう。ただ、真っ先に始める前にアドバイスですが、SizeBinaryはいくつかの新機能を持つBinaryオブジェクトであるため、重要な委譲技術として継承を使用することをお勧めします。
私のソリューション
私自身のアドバイスに従って、私のSizeBinaryクラスはBinaryを継承しています。
code: python
from binary import Binary
class SizeBinary(Binary):
pass
と宣言するだけで、1つのテストが合格し、まだ50のテストが残っています。もちろん、Binaryを継承しない新しいオブジェクトを作ることもできますが、多くの機能を明示的にBinaryクラスに委譲する必要があります。このような場合は、継承のような自動的な委譲の仕組みを利用するのがよいでしょう。この2つの概念については、この記事を読んでください。
バイナリ値を内部に保存しておき、継承版のsuper()を呼び出すたびにアクセスできるようにする、合成も実行可能なソリューションです。しかし、今回のケースでは、継承と合成は似たような結果になり、後者は直観的ではないため、最良の選択ではありません。
また、Binaryクラスで既に実装されている特殊メソッドの多くを再実装する必要があります。これは、Pythonがマジックメソッドを専用のチャネルで解決し、__getattr__() や __getattribute__() メソッドを回避して、全体を高速化しているためです。これにより、マジックメソッドを自動的に委譲することは、メタクラスを使用する以外には不可能になります。
初期化関数は、ビット長を格納し、オーバーフロー条件を知らせるフラグを初期化する。値を変更するset()関数も必要なので、それを実装して __init__() メソッドで呼び出します。
code: python
def __init__(self, bits, value):
self.bits = bits
self.overflow = False
super().__init__(0)
self.set(value)
def set(self, value):
binary = Binary(value)
upper, lower = binary.split(self.bits)
if upper != 0:
self.overflow = True
self._value = Binary(lower)._value
私は、_value属性を設定するBinaryクラスの実装に突っ込むのはそれほど嬉しくないのですが、これは基礎となるBinaryクラスの値を変更する唯一の方法です。もしBinaryクラスにset()メソッドがあれば、super()から呼び出すことができますが、オーバーフロー条件をチェックする必要があるので、__init__()から直接設定することはできません。
このコードでは、37個のテストが合格したという驚くべき結果が得られましたが、14個のテストはまだ合格していませんでした。というのも、多くのテストではSizeBinaryオブジェクトが正しく比較を行っていると仮定していますが、test_binary_equality_checks_bitsテストの失敗が示すように、実際にはそうではないからです。
test_binary_equality_checks_bits テストの失敗が示すように、多くのテストは SizeBinary オブジェクトが正しく比較を行うことを前提としています。実際のところ、この場合、次のようなテストが必要です。
code: python
def test_binary_addition_int():
assert SizeBinary(8, 4) + 1 == SizeBinary(8, 5)
これは、加算の結果がSizeBinaryではなくBinaryであっても、失敗しません。
なぜこのようなことが起こるのか、確認してみましょう。SizeBinaryオブジェクトは、Binaryオブジェクトでもあり、その__add__()メソッドはBinaryを返します。その後の比較は、Binaryクラスの__eq__()メソッドを使用して行われますが、SizeBinaryの方は独自のバージョンの比較を提供していないためです。バイナリクラスは単に値を比較するだけなので、テストは正常に終了します。
正しい比較を行うためのコードを追加してみましょう。
code: python
def __eq__(self, other):
return super().__eq__(other) and self.bits == other.bits
は、Binary クラスがすでに行っているチェックに、ビット数のチェックを加えたものです。この結果、不合格が35回、合格が16回となり、前回の結果が「不正確な」テストに偏っていたことがわかります。実際のところ、比較が正しいかどうかを示すテストが存在することで、それを当然とするテストがあることは完全に合理的です。
簡単に追加できるのは、指定されたビット数よりも短い入力値に対して、0を使った正しいパディングを行うことです。
code: python
def __str__(self):
s = super().__str__()
return s.rjust(self.bits, '0')
この単純な追加により、失敗したテストの数は35から30に減りました。さらに2つのテストを満足させる小さな追加事項は、BinaryオブジェクトではなくSizeBinaryオブジェクトを返すsplit()関数です。
code: python
def split(self, upper_bits, lower_bits):
upper, lower = super().split(lower_bits)
return SizeBinary(upper_bits, upper), SizeBinary(lower_bits, lower)
すでに説明したように、1の補数と2の補数の両方を使って、数値の負のバージョンを得ることができます。まず最初に、1の補数について説明します。
値 x の n ビットをすべて反転させる非常にスマートな方法は、(1 << n) - x - 1 を計算することです。この方法がうまくいくことは、長くて退屈な数学の説明を避けて、例を見ればわかります。ここでは、8ビットで表される数字1('00000001')を否定したい。まず、「1 << 8」を計算すると100000000 が得られ、次にこの数字の値1を引くと11111111が得られ、最後に定数の1を引くと111111110が得られます。見てください、この通りです。これはまさに私の oc() 関数に実装するものです。
code: python
def oc(self):
return SizeBinary(self.bits, (1 << self.bits) - self._value - 1)
同じく退屈な数学の教科書から、ある数字の2の補数表現は、その1の補数バージョンに1を加えたものであることがわかるかもしれません。ですから、tc()の実装は簡単です。
実際のところ、この関数は相対テスト (test_size_binary_TC) を通過させません。というわけで、更新されたバトルレポートは、27 failed, 24 passed となりました。
code: python
def tc(self):
return self.oc() + 1
右シフトと左シフトはBinaryクラスと同じですが、結果が与えられたビットに収まらなければなりません。そのため、Binaryクラスの実装に委ねることができます。
code: python
def __lshift__(self, pos):
return SizeBinary(self.bits, super().__lshift__(pos))
def __rshift__(self, pos):
return SizeBinary(self.bits, super().__rshift__(pos))
そして、これだけで5つの関連テストは合格となります。
さて、次は算術関数と論理関数です。値の内部表現は常に基数10であるため、Binaryクラスの算術関数ですでに行ったように、基数10の世界に戻って要求された演算を行い、基数2の表現に戻ることができます。
これらの関数の問題点は、異なるサイズのバイナリが混在している場合、小さい方を「昇格」させて、結果が大きい方のサイズになるようにすることです。この問題を解決するために、いくつかのコードを _max_and_convert() というヘルパー関数に組み込みました。
code: python
def _max_and_convert(self, other):
try:
other_bits = other.bits
sb_other = other
except AttributeError:
other_bits = self.bits
sb_other = SizeBinary(other_bits, other)
return max(self.bits, other_bits), sb_other
もう一方の引数がbits属性を持っているかどうかをチェックし、そうでなければそれをSizeBinaryに変換します。そして、変換された引数と、後者とselfの間の最大の長さを返します。この関数を使って、次のような魔法のメソッドを実装することができます。
code: python
def __add__(self, other):
bits, sb_other = self._max_and_convert(other)
return SizeBinary(bits, super().__add__(sb_other))
このテンプレートは、__add__()、__sub__()、__mul__()、__truediv__()、__and__()、__or__()、__xor__()の実装に使用されます。__invert__() メソッドはよりシンプルで、2番目の引数がありません。
code: python
def __invert__(self):
return SizeBinary(self.bits, super().__invert__())
これらの関数を使って、49のテストが成功し、2つのテストが失敗しました。最初のテストは、欠落しているto_binary()メソッドに依存しています。
code: python
def to_binary(self):
return Binary(self)
スライスは、マジックメソッド __getitem__() に依存しています。このマジックメソッドは、 test_binary_get_bit や test_binary_negative_index などでテストされる単純なインデックスにも使用されます。SizeBinary クラスに要求されている動作は、1 ビットのインデックスを作成するときは 1 文字を返し、スライスするときは SizeBinary または正しい長さを返すことです。次のコードは、両方の状況を処理します。
code: python
def __getitem__(self, key):
res = super().__getitem__(key)
bits = len(list(res))
if bits > 1:
return SizeBinary(bits, res)
else:
return res
まず最初に、基礎となるBinaryオブジェクトのスライス関数を使用しますが、これはすでにテスト済みで正しく動作しています。そして、その結果の長さを計算し、その結果に応じて行動します。
参考資料
Wikipedia
前回の記事